agora inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH v1 1/3] Move the code to restore files via the shell to a separate file.
690+ messages / 3 participants
[nested] [flat]

* [PATCH v1 1/3] Move the code to restore files via the shell to a separate file.
@ 2022-12-24 00:35  Nathan Bossart <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Nathan Bossart @ 2022-12-24 00:35 UTC (permalink / raw)

This is preparatory work for allowing more extensibility in this
area.
---
 src/backend/access/transam/Makefile        |   1 +
 src/backend/access/transam/meson.build     |   1 +
 src/backend/access/transam/shell_restore.c | 194 +++++++++++++++++++++
 src/backend/access/transam/xlog.c          |  44 ++++-
 src/backend/access/transam/xlogarchive.c   | 158 +----------------
 src/include/access/xlogarchive.h           |   7 +-
 6 files changed, 240 insertions(+), 165 deletions(-)
 create mode 100644 src/backend/access/transam/shell_restore.c

diff --git a/src/backend/access/transam/Makefile b/src/backend/access/transam/Makefile
index 661c55a9db..099c315d03 100644
--- a/src/backend/access/transam/Makefile
+++ b/src/backend/access/transam/Makefile
@@ -19,6 +19,7 @@ OBJS = \
 	multixact.o \
 	parallel.o \
 	rmgr.o \
+	shell_restore.o \
 	slru.o \
 	subtrans.o \
 	timeline.o \
diff --git a/src/backend/access/transam/meson.build b/src/backend/access/transam/meson.build
index 65c77531be..a0870217b8 100644
--- a/src/backend/access/transam/meson.build
+++ b/src/backend/access/transam/meson.build
@@ -7,6 +7,7 @@ backend_sources += files(
   'multixact.c',
   'parallel.c',
   'rmgr.c',
+  'shell_restore.c',
   'slru.c',
   'subtrans.c',
   'timeline.c',
diff --git a/src/backend/access/transam/shell_restore.c b/src/backend/access/transam/shell_restore.c
new file mode 100644
index 0000000000..3ddcabd969
--- /dev/null
+++ b/src/backend/access/transam/shell_restore.c
@@ -0,0 +1,194 @@
+/*-------------------------------------------------------------------------
+ *
+ * shell_restore.c
+ *
+ * These recovery functions use a user-specified shell command (e.g., the
+ * restore_command GUC).
+ *
+ * Copyright (c) 2022, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/backend/access/transam/shell_restore.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <signal.h>
+
+#include "access/xlogarchive.h"
+#include "access/xlogrecovery.h"
+#include "common/archive.h"
+#include "storage/ipc.h"
+#include "utils/wait_event.h"
+
+static void ExecuteRecoveryCommand(const char *command,
+								   const char *commandName, bool failOnSignal,
+								   uint32 wait_event_info,
+								   const char *lastRestartPointFileName);
+
+bool
+shell_restore(const char *file, const char *path,
+			  const char *lastRestartPointFileName)
+{
+	char	   *cmd;
+	int			rc;
+
+	/* Build the restore command to execute */
+	cmd = BuildRestoreCommand(recoveryRestoreCommand, path, file,
+							  lastRestartPointFileName);
+	if (cmd == NULL)
+		elog(ERROR, "could not build restore command \"%s\"", cmd);
+
+	ereport(DEBUG3,
+			(errmsg_internal("executing restore command \"%s\"", cmd)));
+
+	/*
+	 * Copy xlog from archival storage to XLOGDIR
+	 */
+	fflush(NULL);
+	pgstat_report_wait_start(WAIT_EVENT_RESTORE_COMMAND);
+	rc = system(cmd);
+	pgstat_report_wait_end();
+
+	pfree(cmd);
+
+	/*
+	 * Remember, we rollforward UNTIL the restore fails so failure here is
+	 * just part of the process... that makes it difficult to determine
+	 * whether the restore failed because there isn't an archive to restore,
+	 * or because the administrator has specified the restore program
+	 * incorrectly.  We have to assume the former.
+	 *
+	 * However, if the failure was due to any sort of signal, it's best to
+	 * punt and abort recovery.  (If we "return false" here, upper levels will
+	 * assume that recovery is complete and start up the database!) It's
+	 * essential to abort on child SIGINT and SIGQUIT, because per spec
+	 * system() ignores SIGINT and SIGQUIT while waiting; if we see one of
+	 * those it's a good bet we should have gotten it too.
+	 *
+	 * On SIGTERM, assume we have received a fast shutdown request, and exit
+	 * cleanly. It's pure chance whether we receive the SIGTERM first, or the
+	 * child process. If we receive it first, the signal handler will call
+	 * proc_exit, otherwise we do it here. If we or the child process received
+	 * SIGTERM for any other reason than a fast shutdown request, postmaster
+	 * will perform an immediate shutdown when it sees us exiting
+	 * unexpectedly.
+	 *
+	 * We treat hard shell errors such as "command not found" as fatal, too.
+	 */
+	if (wait_result_is_signal(rc, SIGTERM))
+		proc_exit(1);
+
+	ereport(wait_result_is_any_signal(rc, true) ? FATAL : DEBUG2,
+			(errmsg("could not restore file \"%s\" from archive: %s",
+					file, wait_result_to_str(rc))));
+
+	return (rc == 0);
+}
+
+void
+shell_archive_cleanup(const char *lastRestartPointFileName)
+{
+	ExecuteRecoveryCommand(archiveCleanupCommand, "archive_cleanup_command",
+						   false, WAIT_EVENT_ARCHIVE_CLEANUP_COMMAND,
+						   lastRestartPointFileName);
+}
+
+void
+shell_recovery_end(const char *lastRestartPointFileName)
+{
+	ExecuteRecoveryCommand(recoveryEndCommand, "recovery_end_command", true,
+						   WAIT_EVENT_RECOVERY_END_COMMAND,
+						   lastRestartPointFileName);
+}
+
+/*
+ * Attempt to execute an external shell command during recovery.
+ *
+ * 'command' is the shell command to be executed, 'commandName' is a
+ * human-readable name describing the command emitted in the logs. If
+ * 'failOnSignal' is true and the command is killed by a signal, a FATAL
+ * error is thrown. Otherwise a WARNING is emitted.
+ *
+ * This is currently used for recovery_end_command and archive_cleanup_command.
+ */
+static void
+ExecuteRecoveryCommand(const char *command, const char *commandName,
+					   bool failOnSignal, uint32 wait_event_info,
+					   const char *lastRestartPointFileName)
+{
+	char		xlogRecoveryCmd[MAXPGPATH];
+	char	   *dp;
+	char	   *endp;
+	const char *sp;
+	int			rc;
+
+	Assert(command && commandName);
+
+	/*
+	 * construct the command to be executed
+	 */
+	dp = xlogRecoveryCmd;
+	endp = xlogRecoveryCmd + MAXPGPATH - 1;
+	*endp = '\0';
+
+	for (sp = command; *sp; sp++)
+	{
+		if (*sp == '%')
+		{
+			switch (sp[1])
+			{
+				case 'r':
+					/* %r: filename of last restartpoint */
+					sp++;
+					strlcpy(dp, lastRestartPointFileName, endp - dp);
+					dp += strlen(dp);
+					break;
+				case '%':
+					/* convert %% to a single % */
+					sp++;
+					if (dp < endp)
+						*dp++ = *sp;
+					break;
+				default:
+					/* otherwise treat the % as not special */
+					if (dp < endp)
+						*dp++ = *sp;
+					break;
+			}
+		}
+		else
+		{
+			if (dp < endp)
+				*dp++ = *sp;
+		}
+	}
+	*dp = '\0';
+
+	ereport(DEBUG3,
+			(errmsg_internal("executing %s \"%s\"", commandName, command)));
+
+	/*
+	 * execute the constructed command
+	 */
+	fflush(NULL);
+	pgstat_report_wait_start(wait_event_info);
+	rc = system(xlogRecoveryCmd);
+	pgstat_report_wait_end();
+
+	if (rc != 0)
+	{
+		/*
+		 * If the failure was due to any sort of signal, it's best to punt and
+		 * abort recovery.  See comments in shell_restore().
+		 */
+		ereport((failOnSignal && wait_result_is_any_signal(rc, true)) ? FATAL : WARNING,
+		/*------
+		   translator: First %s represents a postgresql.conf parameter name like
+		  "recovery_end_command", the 2nd is the value of that parameter, the
+		  third an already translated error message. */
+				(errmsg("%s \"%s\": %s", commandName,
+						command, wait_result_to_str(rc))));
+	}
+}
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 91473b00d9..32225be4a5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -4887,10 +4887,24 @@ CleanupAfterArchiveRecovery(TimeLineID EndOfLogTLI, XLogRecPtr EndOfLog,
 	 * Execute the recovery_end_command, if any.
 	 */
 	if (recoveryEndCommand && strcmp(recoveryEndCommand, "") != 0)
-		ExecuteRecoveryCommand(recoveryEndCommand,
-							   "recovery_end_command",
-							   true,
-							   WAIT_EVENT_RECOVERY_END_COMMAND);
+	{
+		char		lastRestartPointFname[MAXPGPATH];
+		XLogSegNo	restartSegNo;
+		XLogRecPtr	restartRedoPtr;
+		TimeLineID	restartTli;
+
+		/*
+		 * Calculate the archive file cutoff point for use during log shipping
+		 * replication. All files earlier than this point can be deleted from
+		 * the archive, though there is no requirement to do so.
+		 */
+		GetOldestRestartPoint(&restartRedoPtr, &restartTli);
+		XLByteToSeg(restartRedoPtr, restartSegNo, wal_segment_size);
+		XLogFileName(lastRestartPointFname, restartTli, restartSegNo,
+					 wal_segment_size);
+
+		shell_recovery_end(lastRestartPointFname);
+	}
 
 	/*
 	 * We switched to a new timeline. Clean up segments on the old timeline.
@@ -7307,10 +7321,24 @@ CreateRestartPoint(int flags)
 	 * Finally, execute archive_cleanup_command, if any.
 	 */
 	if (archiveCleanupCommand && strcmp(archiveCleanupCommand, "") != 0)
-		ExecuteRecoveryCommand(archiveCleanupCommand,
-							   "archive_cleanup_command",
-							   false,
-							   WAIT_EVENT_ARCHIVE_CLEANUP_COMMAND);
+	{
+		char		lastRestartPointFname[MAXPGPATH];
+		XLogSegNo	restartSegNo;
+		XLogRecPtr	restartRedoPtr;
+		TimeLineID	restartTli;
+
+		/*
+		 * Calculate the archive file cutoff point for use during log shipping
+		 * replication. All files earlier than this point can be deleted from
+		 * the archive, though there is no requirement to do so.
+		 */
+		GetOldestRestartPoint(&restartRedoPtr, &restartTli);
+		XLByteToSeg(restartRedoPtr, restartSegNo, wal_segment_size);
+		XLogFileName(lastRestartPointFname, restartTli, restartSegNo,
+					 wal_segment_size);
+
+		shell_archive_cleanup(lastRestartPointFname);
+	}
 
 	return true;
 }
diff --git a/src/backend/access/transam/xlogarchive.c b/src/backend/access/transam/xlogarchive.c
index e2b7176f2f..50b0d1105d 100644
--- a/src/backend/access/transam/xlogarchive.c
+++ b/src/backend/access/transam/xlogarchive.c
@@ -56,9 +56,8 @@ RestoreArchivedFile(char *path, const char *xlogfname,
 					bool cleanupEnabled)
 {
 	char		xlogpath[MAXPGPATH];
-	char	   *xlogRestoreCmd;
 	char		lastRestartPointFname[MAXPGPATH];
-	int			rc;
+	bool		ret;
 	struct stat stat_buf;
 	XLogSegNo	restartSegNo;
 	XLogRecPtr	restartRedoPtr;
@@ -149,18 +148,6 @@ RestoreArchivedFile(char *path, const char *xlogfname,
 	else
 		XLogFileName(lastRestartPointFname, 0, 0L, wal_segment_size);
 
-	/* Build the restore command to execute */
-	xlogRestoreCmd = BuildRestoreCommand(recoveryRestoreCommand,
-										 xlogpath, xlogfname,
-										 lastRestartPointFname);
-	if (xlogRestoreCmd == NULL)
-		elog(ERROR, "could not build restore command \"%s\"",
-			 recoveryRestoreCommand);
-
-	ereport(DEBUG3,
-			(errmsg_internal("executing restore command \"%s\"",
-							 xlogRestoreCmd)));
-
 	/*
 	 * Check signals before restore command and reset afterwards.
 	 */
@@ -169,15 +156,11 @@ RestoreArchivedFile(char *path, const char *xlogfname,
 	/*
 	 * Copy xlog from archival storage to XLOGDIR
 	 */
-	fflush(NULL);
-	pgstat_report_wait_start(WAIT_EVENT_RESTORE_COMMAND);
-	rc = system(xlogRestoreCmd);
-	pgstat_report_wait_end();
+	ret = shell_restore(xlogfname, xlogpath, lastRestartPointFname);
 
 	PostRestoreCommand();
-	pfree(xlogRestoreCmd);
 
-	if (rc == 0)
+	if (ret)
 	{
 		/*
 		 * command apparently succeeded, but let's make sure the file is
@@ -233,37 +216,6 @@ RestoreArchivedFile(char *path, const char *xlogfname,
 		}
 	}
 
-	/*
-	 * Remember, we rollforward UNTIL the restore fails so failure here is
-	 * just part of the process... that makes it difficult to determine
-	 * whether the restore failed because there isn't an archive to restore,
-	 * or because the administrator has specified the restore program
-	 * incorrectly.  We have to assume the former.
-	 *
-	 * However, if the failure was due to any sort of signal, it's best to
-	 * punt and abort recovery.  (If we "return false" here, upper levels will
-	 * assume that recovery is complete and start up the database!) It's
-	 * essential to abort on child SIGINT and SIGQUIT, because per spec
-	 * system() ignores SIGINT and SIGQUIT while waiting; if we see one of
-	 * those it's a good bet we should have gotten it too.
-	 *
-	 * On SIGTERM, assume we have received a fast shutdown request, and exit
-	 * cleanly. It's pure chance whether we receive the SIGTERM first, or the
-	 * child process. If we receive it first, the signal handler will call
-	 * proc_exit, otherwise we do it here. If we or the child process received
-	 * SIGTERM for any other reason than a fast shutdown request, postmaster
-	 * will perform an immediate shutdown when it sees us exiting
-	 * unexpectedly.
-	 *
-	 * We treat hard shell errors such as "command not found" as fatal, too.
-	 */
-	if (wait_result_is_signal(rc, SIGTERM))
-		proc_exit(1);
-
-	ereport(wait_result_is_any_signal(rc, true) ? FATAL : DEBUG2,
-			(errmsg("could not restore file \"%s\" from archive: %s",
-					xlogfname, wait_result_to_str(rc))));
-
 not_available:
 
 	/*
@@ -277,110 +229,6 @@ not_available:
 	return false;
 }
 
-/*
- * Attempt to execute an external shell command during recovery.
- *
- * 'command' is the shell command to be executed, 'commandName' is a
- * human-readable name describing the command emitted in the logs. If
- * 'failOnSignal' is true and the command is killed by a signal, a FATAL
- * error is thrown. Otherwise a WARNING is emitted.
- *
- * This is currently used for recovery_end_command and archive_cleanup_command.
- */
-void
-ExecuteRecoveryCommand(const char *command, const char *commandName,
-					   bool failOnSignal, uint32 wait_event_info)
-{
-	char		xlogRecoveryCmd[MAXPGPATH];
-	char		lastRestartPointFname[MAXPGPATH];
-	char	   *dp;
-	char	   *endp;
-	const char *sp;
-	int			rc;
-	XLogSegNo	restartSegNo;
-	XLogRecPtr	restartRedoPtr;
-	TimeLineID	restartTli;
-
-	Assert(command && commandName);
-
-	/*
-	 * Calculate the archive file cutoff point for use during log shipping
-	 * replication. All files earlier than this point can be deleted from the
-	 * archive, though there is no requirement to do so.
-	 */
-	GetOldestRestartPoint(&restartRedoPtr, &restartTli);
-	XLByteToSeg(restartRedoPtr, restartSegNo, wal_segment_size);
-	XLogFileName(lastRestartPointFname, restartTli, restartSegNo,
-				 wal_segment_size);
-
-	/*
-	 * construct the command to be executed
-	 */
-	dp = xlogRecoveryCmd;
-	endp = xlogRecoveryCmd + MAXPGPATH - 1;
-	*endp = '\0';
-
-	for (sp = command; *sp; sp++)
-	{
-		if (*sp == '%')
-		{
-			switch (sp[1])
-			{
-				case 'r':
-					/* %r: filename of last restartpoint */
-					sp++;
-					strlcpy(dp, lastRestartPointFname, endp - dp);
-					dp += strlen(dp);
-					break;
-				case '%':
-					/* convert %% to a single % */
-					sp++;
-					if (dp < endp)
-						*dp++ = *sp;
-					break;
-				default:
-					/* otherwise treat the % as not special */
-					if (dp < endp)
-						*dp++ = *sp;
-					break;
-			}
-		}
-		else
-		{
-			if (dp < endp)
-				*dp++ = *sp;
-		}
-	}
-	*dp = '\0';
-
-	ereport(DEBUG3,
-			(errmsg_internal("executing %s \"%s\"", commandName, command)));
-
-	/*
-	 * execute the constructed command
-	 */
-	fflush(NULL);
-	pgstat_report_wait_start(wait_event_info);
-	rc = system(xlogRecoveryCmd);
-	pgstat_report_wait_end();
-
-	if (rc != 0)
-	{
-		/*
-		 * If the failure was due to any sort of signal, it's best to punt and
-		 * abort recovery.  See comments in RestoreArchivedFile().
-		 */
-		ereport((failOnSignal && wait_result_is_any_signal(rc, true)) ? FATAL : WARNING,
-		/*------
-		   translator: First %s represents a postgresql.conf parameter name like
-		  "recovery_end_command", the 2nd is the value of that parameter, the
-		  third an already translated error message. */
-				(errmsg("%s \"%s\": %s", commandName,
-						command, wait_result_to_str(rc))));
-	}
-}
-
-
 /*
  * A file was restored from the archive under a temporary filename (path),
  * and now we want to keep it. Rename it under the permanent filename in
diff --git a/src/include/access/xlogarchive.h b/src/include/access/xlogarchive.h
index f47b219538..69d002cdeb 100644
--- a/src/include/access/xlogarchive.h
+++ b/src/include/access/xlogarchive.h
@@ -20,8 +20,6 @@
 extern bool RestoreArchivedFile(char *path, const char *xlogfname,
 								const char *recovername, off_t expectedSize,
 								bool cleanupEnabled);
-extern void ExecuteRecoveryCommand(const char *command, const char *commandName,
-								   bool failOnSignal, uint32 wait_event_info);
 extern void KeepFileRestoredFromArchive(const char *path, const char *xlogfname);
 extern void XLogArchiveNotify(const char *xlog);
 extern void XLogArchiveNotifySeg(XLogSegNo segno, TimeLineID tli);
@@ -32,4 +30,9 @@ extern bool XLogArchiveIsReady(const char *xlog);
 extern bool XLogArchiveIsReadyOrDone(const char *xlog);
 extern void XLogArchiveCleanup(const char *xlog);
 
+extern bool shell_restore(const char *file, const char *path,
+						  const char *lastRestartPointFileName);
+extern void shell_archive_cleanup(const char *lastRestartPointFileName);
+extern void shell_recovery_end(const char *lastRestartPointFileName);
+
 #endif							/* XLOG_ARCHIVE_H */
-- 
2.25.1


--Qxx1br4bt0+wmkIi
Content-Type: text/x-diff; charset=us-ascii
Content-Disposition: attachment;
	filename="v1-0002-Refactor-code-for-restoring-files-via-shell.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v46 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a58277bb6c1..21ab81c6f7f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3123,10 +3130,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3346,9 +3361,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--mdsv5bdgfjjj6d77
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v46-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v47 8/9] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9c807f75d71..3acb2e1d2ba 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3287,9 +3303,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--2fxmamo6mu2qbxgv
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v47-0009-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v45 7/8] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 53 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index d25ab43da7b..7291b4180ab 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us
+	 * to error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3119,10 +3126,18 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
-	/* The same for indexes. */
 	for (int i = 0; i < nind; i++)
 	{
 		Relation	index = ind_refs[i];
@@ -3342,9 +3357,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..3d3ec0da111 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some process
+					 * running REPACK (CONCURRENTLY), just fail.  That process
+					 * is going to upgrade its lock at some point, and it would
+					 * be inappropriate for any other process to cause that
+					 * to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+									   MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cldir6umqisr673d
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v45-0008-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v48 6/7] Error out any process that would block at REPACK
@ 2026-03-25 19:35  Álvaro Herrera <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Álvaro Herrera @ 2026-03-25 19:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 52 ++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 202 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..781a471fc1d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,16 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +486,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +519,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1007,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,16 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3304,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qfkt2ktdpcfeypib
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v48-0007-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--=-=-=--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v53 6/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 8d9b2b2e370..92759f33969 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3369,9 +3388,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qr3jlalmmcpkiodg
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v53-0007-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v54 4/5] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index b0898ce52d9..d1be6c60e88 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -292,6 +292,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -494,11 +506,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1004,10 +1013,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3096,7 +3103,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3370,9 +3389,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 22822fc68d7..a43cfe53558 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--cdhh4t7ukb6tnjoq
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v54-0005-Reserve-replication-slots-specifically-for-REPAC.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v56 3/3] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 5e97fcf3818..4dae0e8ebe0 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--qs77q6fpwolne4ds--





^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH 1/2] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 47 ++++++++---
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 201 insertions(+), 15 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 20dad22c4b7..a5f5df77291 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -285,6 +285,18 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 		 * to understand and we don't lose any functionality.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
+
+		/*
+		 * Also set the PROC_IN_CONCURRENT_REPACK flag.  This makes the
+		 * deadlock checker cause anyone that would conflict with us to error
+		 * out.  It's important to set this flag ahead of actually locking the
+		 * relation; it won't of course affect anyone until we do have a lock
+		 * that others can conflict with.
+		 */
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
 	}
 
 	/*
@@ -489,11 +501,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -1002,10 +1011,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3097,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3364,9 +3383,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 3e1d1fad5f9..76c6bb44251 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -70,10 +70,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index f057d143d1a..13c873969d1 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fb1418e2caa..ead18818c83 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--kdrcpfmkbkc4lqhu
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="0002-Publish-list-of-tables-being-repacked-in-shared-memo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v49 5/7] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index a514053b777..274ad900c65 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -276,6 +276,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -476,11 +491,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -512,10 +524,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -998,10 +1012,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3090,7 +3102,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3288,9 +3312,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--3n3vqr5y2jhknrqj
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v49-0006-Teach-snapshot-builder-to-skip-transactions-runn.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v50 6/8] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--brnevsqjnuzpyok4
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v50-0007-Introduce-an-option-to-make-logical-replication-.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v51 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--r2slln3zpmilwu22
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v51-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread

* [PATCH v52 06/10] Error out any process that would block at REPACK
@ 2026-04-01 15:35  Antonin Houska <[email protected]>
  0 siblings, 0 replies; 690+ messages in thread

From: Antonin Houska @ 2026-04-01 15:35 UTC (permalink / raw)

Any process waiting on REPACK to release its lock would actually cause
it to deadlock when it tries to upgrade its lock to AEL, losing all work
done to that point.  We avoid this by teaching the deadlock detector to
raise an error when this condition is detected.
---
 src/backend/commands/repack.c                 | 60 ++++++++++----
 src/backend/storage/lmgr/deadlock.c           | 15 ++++
 src/include/storage/proc.h                    |  6 +-
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/repack_deadlock.out              | 63 ++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/repack_deadlock.spec                | 83 +++++++++++++++++++
 7 files changed, 210 insertions(+), 19 deletions(-)
 create mode 100644 src/test/modules/injection_points/expected/repack_deadlock.out
 create mode 100644 src/test/modules/injection_points/specs/repack_deadlock.spec

diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 03829892d57..d6e446d582d 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -279,6 +279,21 @@ ExecRepack(ParseState *pstate, RepackStmt *stmt, bool isTopLevel)
 	/* Determine the lock mode to use. */
 	lockmode = RepackLockLevel((params.options & CLUOPT_CONCURRENT) != 0);
 
+	/*
+	 * If in concurrent mode, set the PROC_IN_CONCURRENT_REPACK flag.  This
+	 * makes the deadlock checker cause anyone that would conflict with us to
+	 * error out.  It's important to set this flag ahead of actually locking
+	 * the relation; it won't of course affect anyone until we do have a lock
+	 * that others can conflict with.
+	 */
+	if ((params.options & CLUOPT_CONCURRENT) != 0)
+	{
+		LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+		MyProc->statusFlags |= PROC_IN_CONCURRENT_REPACK;
+		ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+		LWLockRelease(ProcArrayLock);
+	}
+
 	/*
 	 * If a single relation is specified, process it and we're done ... unless
 	 * the relation is a partitioned table, in which case we fall through.
@@ -479,11 +494,8 @@ RepackLockLevel(bool concurrent)
  * If indexOid is InvalidOid, the table will be rewritten in physical order
  * instead of index order.
  *
- * Note that, in the concurrent case, the function releases the lock at some
- * point, in order to get AccessExclusiveLock for the final steps (i.e. to
- * swap the relation files). To make things simpler, the caller should expect
- * OldHeap to be closed on return, regardless CLUOPT_CONCURRENT. (The
- * AccessExclusiveLock is kept till the end of the transaction.)
+ * On return, OldHeap is closed but locked with AccessExclusiveLock - the lock
+ * will be released at end of the transaction.
  *
  * 'cmd' indicates which command is being executed, to be used for error
  * messages.
@@ -515,10 +527,12 @@ cluster_rel(RepackCommand cmd, Relation OldHeap, Oid indexOid,
 		/*
 		 * Make sure we're not in a transaction block.
 		 *
-		 * The reason is that repack_setup_logical_decoding() could deadlock
-		 * if there's an XID already assigned.  It would be possible to run in
-		 * a transaction block if we had no XID, but this restriction is
-		 * simpler for users to understand and we don't lose anything.
+		 * The reason is that repack_setup_logical_decoding() could wait
+		 * indefinitely for our XID to complete. (The deadlock detector would
+		 * not recognize it because we'd be waiting for ourselves, i.e. no
+		 * real lock conflict.) It would be possible to run in a transaction
+		 * block if we had no XID, but this restriction is simpler for users
+		 * to understand and we don't lose anything.
 		 */
 		PreventInTransactionBlock(isTopLevel, "REPACK (CONCURRENTLY)");
 
@@ -1001,10 +1015,8 @@ rebuild_relation(Relation OldHeap, Relation index, bool verbose,
 		 * Note that the worker has to wait for all transactions with XID
 		 * already assigned to finish. If some of those transactions is
 		 * waiting for a lock conflicting with ShareUpdateExclusiveLock on our
-		 * table (e.g.  it runs CREATE INDEX), we can end up in a deadlock.
-		 * Not sure this risk is worth unlocking/locking the table (and its
-		 * clustering index) and checking again if it's still eligible for
-		 * REPACK CONCURRENTLY.
+		 * table (e.g. it runs CREATE INDEX), it should encounter ERROR in the
+		 * deadlock checking code.
 		 */
 		start_repack_decoding_worker(tableOid);
 
@@ -3093,7 +3105,19 @@ rebuild_relation_finish_concurrent(Relation NewHeap, Relation OldHeap,
 		LockRelationOid(OldHeap->rd_rel->reltoastrelid, AccessExclusiveLock);
 
 	/*
-	 * Tuples and pages of the old heap will be gone, but the heap will stay.
+	 * Now that we have all access-exclusive locks on all relations, we no
+	 * longer want other processes to error out when trying to acquire a
+	 * conflicting lock.  Therefore, unset our flag.
+	 */
+	LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE);
+	MyProc->statusFlags &= ~PROC_IN_CONCURRENT_REPACK;
+	ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags;
+	LWLockRelease(ProcArrayLock);
+
+	/*
+	 * Tuples and pages of the old heap will be gone, but the heap itself will
+	 * stay.  In order for predicate locks to continue to work, convert them
+	 * to relation-level locks.  We do this both for table and indexes.
 	 */
 	TransferPredicateLocksToHeapRelation(OldHeap);
 	foreach_ptr(RelationData, index, indexrels)
@@ -3366,9 +3390,11 @@ start_repack_decoding_worker(Oid relid)
 
 	/*
 	 * The decoding setup must be done before the caller can have XID assigned
-	 * for any reason, otherwise the worker might end up in a deadlock,
-	 * waiting for the caller's transaction to end. Therefore wait here until
-	 * the worker indicates that it has the logical decoding initialized.
+	 * for any reason, otherwise the worker might end up waiting for the
+	 * caller's transaction to end. (Deadlock detector does not consider this
+	 * a conflict because the worker is in the same locking group as the
+	 * backend that launched it.) Therefore wait here until the worker
+	 * indicates that it has the logical decoding initialized.
 	 */
 	ConditionVariablePrepareToSleep(&shared->cv);
 	for (;;)
diff --git a/src/backend/storage/lmgr/deadlock.c b/src/backend/storage/lmgr/deadlock.c
index b8962d875b6..c20ac682b0d 100644
--- a/src/backend/storage/lmgr/deadlock.c
+++ b/src/backend/storage/lmgr/deadlock.c
@@ -620,6 +620,21 @@ FindLockCycleRecurseMember(PGPROC *checkProc,
 						proc->statusFlags & PROC_IS_AUTOVACUUM)
 						blocking_autovacuum_proc = proc;
 
+					/*
+					 * Similarly, if we note that we're blocked by some
+					 * process running REPACK (CONCURRENTLY), just fail.  That
+					 * process is going to upgrade its lock at some point, and
+					 * it would be inappropriate for any other process to
+					 * cause that to fail.
+					 */
+					if (checkProc == MyProc &&
+						proc->statusFlags & PROC_IN_CONCURRENT_REPACK)
+						ereport(ERROR,
+								errcode(ERRCODE_OBJECT_IN_USE),
+								errmsg("could not wait for concurrent REPACK"),
+								errdetail("Process %d waits for REPACK running on process %d",
+										  MyProc->pid, proc->pid));
+
 					/* We're done looking at this proclock */
 					break;
 				}
diff --git a/src/include/storage/proc.h b/src/include/storage/proc.h
index 1dad125706e..8ad9718f3d6 100644
--- a/src/include/storage/proc.h
+++ b/src/include/storage/proc.h
@@ -69,10 +69,12 @@ struct XidCache
 #define		PROC_AFFECTS_ALL_HORIZONS	0x20	/* this proc's xmin must be
 												 * included in vacuum horizons
 												 * in all databases */
+#define		PROC_IN_CONCURRENT_REPACK	0x40	/* REPACK (CONCURRENTLY) */
 
-/* flags reset at EOXact */
+/* flags reset at EOXact.  A bit of a misnomer ... */
 #define		PROC_VACUUM_STATE_MASK \
-	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND)
+	(PROC_IN_VACUUM | PROC_IN_SAFE_IC | PROC_VACUUM_FOR_WRAPAROUND | \
+	 PROC_IN_CONCURRENT_REPACK)
 
 /*
  * Xmin-related flags. Make sure any flags that affect how the process' Xmin
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 2cd7d87c533..f7663859fe2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -15,6 +15,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 ISOLATION = basic \
 	    inplace \
 	    repack \
+	    repack_deadlock \
 	    repack_toast \
 	    syscache-update-pruned \
 	    heap_lock_update
diff --git a/src/test/modules/injection_points/expected/repack_deadlock.out b/src/test/modules/injection_points/expected/repack_deadlock.out
new file mode 100644
index 00000000000..a86e4767536
--- /dev/null
+++ b/src/test/modules/injection_points/expected/repack_deadlock.out
@@ -0,0 +1,63 @@
+Parsed test spec with 2 sessions
+
+starting permutation: wait_before_lock add_column wakeup_before_lock check1
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: 
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+ <waiting ...>
+step add_column: 
+	alter table repack_deadlock add column noise text;
+ <waiting ...>
+step add_column: <... completed>
+ERROR:  could not wait for concurrent REPACK
+step wakeup_before_lock: 
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step wait_before_lock: <... completed>
+step check1: 
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+
+count
+-----
+    1
+(1 row)
+
+i|j
+-+-
+1|1
+2|2
+3|3
+4|4
+(4 rows)
+
+count
+-----
+    4
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index a414abb924b..1cd88d6db65 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -46,6 +46,7 @@ tests += {
       'basic',
       'inplace',
       'repack',
+      'repack_deadlock',
       'repack_toast',
       'syscache-update-pruned',
       'heap_lock_update',
diff --git a/src/test/modules/injection_points/specs/repack_deadlock.spec b/src/test/modules/injection_points/specs/repack_deadlock.spec
new file mode 100644
index 00000000000..9d23a6588c2
--- /dev/null
+++ b/src/test/modules/injection_points/specs/repack_deadlock.spec
@@ -0,0 +1,83 @@
+# Test REPACK with a concurrent transaction that would cause a deadlock
+setup
+{
+	CREATE EXTENSION injection_points;
+
+	CREATE TABLE repack_deadlock(i int PRIMARY KEY, j int);
+	INSERT INTO repack_deadlock(i, j) VALUES (1, 1), (2, 2), (3, 3), (4, 4);
+
+	CREATE TABLE relfilenodes(node oid);
+
+	CREATE TABLE data_s1(i int, j int);
+	CREATE TABLE data_s2(i int, j int);
+}
+
+teardown
+{
+	DROP TABLE repack_deadlock;
+	DROP EXTENSION injection_points;
+
+	DROP TABLE relfilenodes;
+	DROP TABLE data_s1;
+	DROP TABLE data_s2;
+}
+
+session s1
+setup
+{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('repack-concurrently-before-lock', 'wait');
+}
+# Perform the initial load and wait for s2 to do some data changes.
+step wait_before_lock
+{
+	REPACK (CONCURRENTLY) repack_deadlock USING INDEX repack_deadlock_pkey;
+}
+# Check the table from the perspective of s1.
+#
+# Besides the contents, we also check that relfilenode has changed.
+
+# Have each session write the contents into a table and use FULL JOIN to check
+# if the outputs are identical.
+step check1
+{
+	INSERT INTO relfilenodes(node)
+	SELECT relfilenode FROM pg_class WHERE relname='repack_deadlock';
+
+	SELECT count(DISTINCT node) FROM relfilenodes;
+
+	SELECT i, j FROM repack_deadlock ORDER BY i, j;
+
+	INSERT INTO data_s1(i, j)
+	SELECT i, j FROM repack_deadlock;
+
+	SELECT count(*)
+	FROM data_s1 d1 FULL JOIN data_s2 d2 USING (i, j)
+	WHERE d1.i ISNULL OR d2.i ISNULL;
+}
+teardown
+{
+	SELECT injection_points_detach('repack-concurrently-before-lock');
+}
+
+session s2
+# Change the existing data. UPDATE changes both key and non-key columns. Also
+# update one row twice to test whether tuple version generated by this session
+# can be found.
+step add_column
+{
+	alter table repack_deadlock add column noise text;
+}
+
+step wakeup_before_lock
+{
+	SELECT injection_points_wakeup('repack-concurrently-before-lock');
+}
+
+# Test if data changes introduced while one session is performing REPACK
+# CONCURRENTLY find their way into the table.
+permutation
+	wait_before_lock
+	add_column
+	wakeup_before_lock
+	check1
-- 
2.47.3


--gp2pyozrd5pweboh
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v52-0007-Check-for-transaction-block-early-in-ExecRepack.patch"



^ permalink  raw  reply  [nested|flat] 690+ messages in thread


end of thread, other threads:[~2026-04-01 15:35 UTC | newest]

Thread overview: 690+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2022-12-24 00:35 [PATCH v1 1/3] Move the code to restore files via the shell to a separate file. Nathan Bossart <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v47 8/9] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v46 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v45 7/8] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-03-25 19:35 [PATCH v48 6/7] Error out any process that would block at REPACK Álvaro Herrera <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v53 6/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v51 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v52 06/10] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v56 3/3] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH 1/2] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v54 4/5] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v49 5/7] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH v50 6/8] Error out any process that would block at REPACK Antonin Houska <[email protected]>
2026-04-01 15:35 [PATCH] Error out any process that would block at REPACK Antonin Houska <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox